ATOM Documentation

← Back to App

Frontend State Management Standard

Overview

This document defines the standard pattern for managing async state (loading, data, error) in React components. Consistent state management improves code quality, reduces bugs, and enhances user experience.

Standard Pattern

State Structure

All async state MUST follow this structure:

{
  data: T | null
  isLoading: boolean
  error: Error | null
}

Hook Usage

**Location**: src/hooks/useAsyncState.ts

**Import**:

import { useAsyncState } from '@/hooks/useAsyncState'

Implementation Examples

Basic Usage

// ✅ GOOD: Using useAsyncState hook
import { useAsyncState } from '@/hooks/useAsyncState'

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error, execute } = useAsyncState(
    async () => fetchUser(userId)
  )

  useEffect(() => {
    execute()
  }, [userId, execute])

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  if (!data) return <div>No data</div>

  return <div>{data.name}</div>
}

Auto-Execute on Mount

// ✅ GOOD: Auto-execute with useAsyncStateWithExecute
import { useAsyncStateWithExecute } from '@/hooks/useAsyncState'

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error, refresh } = useAsyncStateWithExecute(
    async () => fetchUser(userId),
    [userId] // Dependencies to re-fetch
  )

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  if (!data) return <div>No data</div>

  return (
    <div>
      <h1>{data.name}</h1>
      <button onClick={refresh}>Refresh</button>
    </div>
  )
}

Multiple States

// ✅ GOOD: Multiple related states
function Dashboard({ userId }: { userId: string }) {
  const users = useAsyncState(() => fetchUsers(), [])
  const posts = useAsyncState(() => fetchPosts(), [])

  useEffect(() => {
    users.execute()
    posts.execute()
  }, [])

  if (users.isLoading || posts.isLoading) return <div>Loading...</div>

  return (
    <div>
      <UsersList data={users.data} />
      <PostsList data={posts.data} />
    </div>
  )
}

Before vs After

Before (Inconsistent)

// ❌ BAD: Inconsistent naming
const [loading, setLoading] = useState(false)
const [user, setUser] = useState(null)
const [err, setErr] = useState(null)

// ❌ BAD: Different order
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)

// ❌ BAD: Multiple useState hooks
const [isLoading, setIsLoading] = useState(false)
const [data, setData] = useState(null)
const [error, setError] = useState(null)

After (Standard)

// ✅ GOOD: Single hook with standard naming
const { data, isLoading, error } = useAsyncState(fetchData)

// ✅ GOOD: Destructure in consistent order
const [data, isLoading, error] = extractState(useAsyncState(fetchData))

Loading States

Skeleton Loading

function UserList() {
  const { data, isLoading, error } = useAsyncState(fetchUsers)

  if (isLoading) {
    return <UserListSkeleton />
  }

  if (error) {
    return <ErrorMessage error={error} />
  }

  return <UserList data={data} />
}

Inline Loading

function UserCard({ userId }: { userId: string }) {
  const { data, isLoading } = useAsyncState(() => fetchUser(userId))

  return (
    <div className="user-card">
      {isLoading ? (
        <div className="skeleton">Loading...</div>
      ) : (
        <div>{data?.name}</div>
      )}
    </div>
  )
}

Error Handling

Display Errors

function UserList() {
  const { data, isLoading, error } = useAsyncState(fetchUsers)

  if (error) {
    return (
      <div className="error-message">
        <h3>Failed to load users</h3>
        <p>{error.message}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    )
  }

  return <div>{data?.map(user => <UserCard key={user.id} user={user} />)}</div>
}

Retry Logic

function UserList() {
  const { data, isLoading, error, execute } = useAsyncState(fetchUsers)

  useEffect(() => {
    execute()
  }, [])

  if (error) {
    return (
      <div className="error-message">
        <p>Failed to load: {error.message}</p>
        <button onClick={() => execute()}>Retry</button>
      </div>
    )
  }

  return <div>{data?.map(user => <UserCard key={user.id} user={user} />)}</div>
}

Helper Functions

Check State

import { is_loading, has_data, has_error, get_state_status } from '@/hooks/useAsyncState'

function UserList() {
  const state = useAsyncState(fetchUsers)

  if (is_loading(state)) return <div>Loading...</div>
  if (has_error(state)) return <div>Error: {state.error.message}</div>
  if (has_data(state)) return <div>{state.data.length} users</div>

  return <div>No data</div>
}

Combine States

import { combineStates } from '@/hooks/useAsyncState'

function Dashboard() {
  const users = useAsyncState(fetchUsers)
  const posts = useAsyncState(fetchPosts)

  const combined = combineStates([users, posts])

  if (combined.isLoading) return <div>Loading...</div>
  if (combined.error) return <div>Error: {combined.error.message}</div>

  return (
    <div>
      <UserList data={users.data} />
      <PostList data={posts.data} />
    </div>
  )
}

Best Practices

1. Always Use Standard Naming

// ✅ GOOD: Standard naming
const { data, isLoading, error } = useAsyncState(fetchData)

// ❌ BAD: Non-standard naming
const { users, loading, err } = useAsyncState(fetchUsers)

2. Destructure in Consistent Order

// ✅ GOOD: data, isLoading, error
const { data, isLoading, error } = useAsyncState(fetchData)

// ❌ BAD: Different order
const { error, isLoading, data } = useAsyncState(fetchData)

3. Handle All States

// ✅ GOOD: Handle all states
const { data, isLoading, error } = useAsyncState(fetchData)

if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!data) return <EmptyState />

return <DataDisplay data={data} />

// ❌ BAD: Missing states
const { data, isLoading } = useAsyncState(fetchData)

if (isLoading) return <LoadingSpinner />
return <DataDisplay data={data} /> // What about error?

4. Provide Loading Feedback

// ✅ GOOD: Always show loading state
const { data, isLoading } = useAsyncState(fetchData)

return (
  <div>
    {isLoading ? <Skeleton /> : <DataDisplay data={data} />}
  </div>
)

// ❌ BAD: No loading feedback
const { data, isLoading } = useAsyncState(fetchData)

return <DataDisplay data={data} /> // User doesn't know it's loading

5. Use TypeScript

// ✅ GOOD: Type the data
interface User {
  id: string
  name: string
}

const { data, isLoading } = useAsyncState<User>(fetchUser)

// Now data is properly typed
data?.name // TypeScript knows this is a string

// ❌ BAD: No typing
const { data } = useAsyncState(fetchUser)
data?.name // TypeScript doesn't know the type

Migration Guide

Before

function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function load() {
      setLoading(true)
      setError(null)
      try {
        const data = await fetchUsers()
        setUsers(data)
      } catch (err) {
        setError(err)
      } finally {
        setLoading(false)
      }
    }
    load()
  }, [])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error</div>

  return <div>{users.map(u => <User key={u.id} user={u} />)}</div>
}

After

function UserList() {
  const { data, isLoading, error, execute } = useAsyncState(fetchUsers)

  useEffect(() => {
    execute()
  }, [execute])

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error</div>

  return <div>{data?.map(u => <User key={u.id} user={u} />)}</div>
}

Testing

Test Hook Behavior

import { renderHook, waitFor } from '@testing-library/react'
import { useAsyncState } from '@/hooks/useAsyncState'

test('loads data successfully', async () => {
  const { result } = renderHook(() => useAsyncState(async () => {
    return await fetchData()
  }))

  await result.current.execute()

  expect(result.current.isLoading).toBe(false)
  expect(result.current.data).toEqual(expectedData)
  expect(result.current.error).toBe(null)
})

Test Error Handling

test('handles errors', async () => {
  const { result } = renderHook(() => useAsyncState(async () => {
    throw new Error('Failed')
  }))

  await result.current.execute()

  expect(result.current.isLoading).toBe(false)
  expect(result.current.error).toBeInstanceOf(Error)
  expect(result.current.error?.message).toBe('Failed')
})

TypeScript Types

State Type

import { AsyncState } from '@/hooks/useAsyncState'

interface User {
  id: string
  name: string
}

const state: AsyncState<User> = {
  data: null,
  isLoading: false,
  error: null
}

Hook Return Type

import { UseAsyncStateReturn } from '@/hooks/useAsyncState'

interface User {
  id: string
  name: string
}

const state: UseAsyncStateReturn<User> = useAsyncState<User>(fetchUser)

Performance

Memoization

The hook automatically handles performance optimizations:

// ✅ GOOD: No need for manual memoization
const { data, isLoading } = useAsyncState(fetchData)

// ❌ BAD: Unnecessary useMemo
const state = useMemo(() => useAsyncState(fetchData), [])

Cleanup

The hook handles cleanup automatically:

// ✅ GOOD: Automatic cleanup
const { execute } = useAsyncState(fetchData)

useEffect(() => {
  execute()
}, [])

// Cleanup happens automatically when component unmounts

References

  • Implementation: src/hooks/useAsyncState.ts
  • React Hooks: https://react.dev/reference/react
  • TypeScript: https://www.typescriptlang.org/

Changelog

  • 2026-02-08: Initial standard created
  • 2026-02-08: Hook implementation created
  • 2026-02-08: Helper functions documented